Skip to content

feat: kotlin dsl#6

Draft
rafbcampos wants to merge 8 commits intomainfrom
kotlin-dsl
Draft

feat: kotlin dsl#6
rafbcampos wants to merge 8 commits intomainfrom
kotlin-dsl

Conversation

@rafbcampos
Copy link
Contributor

@rafbcampos rafbcampos commented Feb 25, 2026

Description

This PR adds two new modules for authoring Player-UI content in Kotlin:

  1. language/dsl/kotlin - A Kotlin DSL library for building Player-UI flows and assets programmatically
  2. language/generators/kotlin - A code generator that produces Kotlin DSL builders from XLR JSON schemas

Motivation

Kotlin/Java developers currently lack a type-safe way to construct Player-UI content. This DSL provides compile-time validation and IDE support when building flows, views, and assets.

1. Generate Asset Builders

Run the generator CLI against your XLR JSON definitions:

bazel run //generators/kotlin:generator-cli -- \
  -i path/to/xlr/ActionAsset.json \
  -i path/to/xlr/TextAsset.json \
  -o generated \
  -p com.myapp.builders

Or point at a directory to process all XLR files at once:

bazel run //generators/kotlin:generator-cli -- \
  -i path/to/xlr/ \
  -o generated \
  -p com.myapp.builders

This produces one Kotlin file per asset type (e.g. TextBuilder.kt, ActionBuilder.kt).

2. Generate Schema Bindings

Generate type-safe bindings from a Player-UI schema JSON:

bazel run //generators/kotlin:generator-cli -- \
  -s path/to/my-schema.json \
  --schema-name MySchema \
  -o generated \
  -p com.myapp.schema

Given this schema:

{
  "ROOT": {
    "user": { "type": "UserType" },
    "items": { "type": "ItemType", "isArray": true }
  },
  "UserType": {
    "firstName": { "type": "StringType" },
    "email": { "type": "StringType" },
    "age": { "type": "NumberType" }
  },
  "ItemType": {
    "id": { "type": "StringType" },
    "label": { "type": "StringType" }
  }
}

The generator produces:

object MySchema {
    object user {
        val firstName: Binding<String> = Binding("user.firstName")
        val email: Binding<String> = Binding("user.email")
        val age: Binding<Number> = Binding("user.age")
    }

    object items {
        val id: Binding<String> = Binding("items._current_.id")
        val label: Binding<String> = Binding("items._current_.label")
    }
}

Array types automatically use _current_ placeholders in their paths.

Usage

Building Assets

Every generated asset type provides two equivalent patterns: a DSL lambda (idiomatic Kotlin) and a fluent chaining API.

DSL Pattern (Lambda)

import com.myapp.builders.*
import com.myapp.schema.MySchema

// Simple text
val greeting = text { value = "Hello World" }

// Text with a schema binding
val nameDisplay = text { value(MySchema.user.firstName) }

// Input bound to schema
val emailInput = input {
    binding = MySchema.user.email
    label(text { value = "Email Address" })
    note(text { value = "We'll never share your email" })
}

// Action with label, expression, and metadata
val submitButton = action {
    value = "submit"
    label(text { value = "Register" })
    exp = expression<Unit>("validateForm()")
    metaData {
        role = "primary"
        skipValidation = false
        beacon = "submit-beacon"
    }
}

// Image with metadata and caption
val banner = image {
    metaData {
        ref = "https://example.com/banner.png"
        accessibility = "Welcome banner"
    }
    placeholder = "Loading..."
    caption(text { value = "Our company banner" })
}

// Collection grouping multiple assets
val form = collection {
    label(text { value = "Registration Form" })
    values(
        emailInput,
        input {
            binding = MySchema.user.firstName
            label(text { value = "First Name" })
        },
    )
}

// Choice with items
val planPicker = choice {
    title(text { value = "Select your plan" })
    binding = MySchema.user.plan
    items(
        mapOf("id" to "free", "label" to "Free", "value" to "free"),
        mapOf("id" to "pro", "label" to "Pro", "value" to "pro"),
    )
}

Fluent Pattern (Chaining)

val greeting = text()
    .withValue("Hello World")
    .build()

val emailInput = input()
    .withBinding(MySchema.user.email)
    .withLabel(text { value = "Email Address" })
    .withNote(text { value = "We'll never share your email" })
    .build()

val submitButton = action()
    .withValue("submit")
    .withLabel(text { value = "Register" })
    .withAccessibility("Submit the registration form")
    .withMetaData { role = "primary" }
    .build()

val banner = image()
    .withMetaData {
        ref = "https://example.com/banner.png"
        accessibility = "Welcome banner"
    }
    .withPlaceholder("Loading...")
    .withCaption(text { value = "Our company banner" })
    .build()

val form = collection()
    .withLabel(text { value = "Registration Form" })
    .withValues(listOf(
        input { binding = MySchema.user.email; label(text { value = "Email" }) },
        input { binding = MySchema.user.firstName; label(text { value = "First Name" }) },
    ))
    .build()

Both patterns produce identical output. Use whichever reads better for your use case, the DSL pattern works well for nested structures, while the fluent pattern is convenient for simple or programmatic construction.

Tagged Values: Bindings and Expressions

Bindings and expressions are the two types of dynamic values in Player-UI.

import com.myapp.schema.MySchema
import com.intuit.playerui.lang.dsl.tagged.*

// Schema bindings (generated, type-safe)
MySchema.user.firstName          // Binding<String>  -> "{{user.firstName}}"
MySchema.user.age                // Binding<Number>  -> "{{user.age}}"
MySchema.items.label             // Binding<String>  -> "{{items._current_.label}}"

// Manual bindings (when schema isn't available)
val custom = binding<String>("custom.path")  // -> "{{custom.path}}"

// Binding path composition with / operator
val base = binding<Any>("user")
val nested = base / "profile" / "name"       // -> "{{user.profile.name}}"

// Expressions
val greeting = expression<String>("concat('Hello, ', firstName)")  // -> "@[concat('Hello, ', firstName)]@"
val validate = expression<Unit>("validateForm()")                   // -> "@[validateForm()]@"

Use bindings and expressions anywhere a static value can go:

// Binding as text value
text { value(MySchema.user.firstName) }

// Expression as text value
text { value(expression<String>("concat(firstName, ' ', lastName)")) }

// Binding as input target
input { binding = MySchema.user.email }

// Expression on an action
action {
    value = "submit"
    exp = expression<Unit>("validateForm()")
}

Standard Expression Library

The DSL provides built-in functions for common operations:

import com.intuit.playerui.lang.dsl.tagged.*

// Comparison
greaterThanOrEqual(MySchema.user.age, 18)    // -> @[user.age >= 18]@
lessThan(MySchema.user.age, 65)              // -> @[user.age < 65]@
equal(MySchema.user.plan, "pro")             // -> @[user.plan == "pro"]@

// Logical
and(
    greaterThanOrEqual(MySchema.user.age, 18),
    equal(MySchema.user.verified, true),
)   // -> @[user.age >= 18 && user.verified == true]@

or(
    equal(MySchema.user.plan, "pro"),
    equal(MySchema.user.plan, "enterprise"),
)   // -> @[user.plan == "pro" || user.plan == "enterprise"]@

not(equal(MySchema.user.verified, true))     // -> @[!(user.verified == true)]@

// Arithmetic
add(MySchema.user.age, 1)                   // -> @[user.age + 1]@
multiply(binding<Number>("price"), binding<Number>("quantity"))

// Conditional (ternary)
conditional(
    greaterThanOrEqual(MySchema.user.age, 18),
    "Adult",
    "Minor",
)   // -> @[user.age >= 18 ? "Adult" : "Minor"]@

// Function calls
call<Unit>("trackEvent", "page_view", MySchema.user.firstName)

Building Complete Flows

A flow combines views, data, navigation, and optionally a schema into a complete Player-UI content structure.

import com.intuit.playerui.lang.dsl.flow.flow
import com.intuit.playerui.lang.dsl.flow.navigation
import com.myapp.builders.*
import com.myapp.schema.MySchema

val registrationFlow = flow {
    id = "registration"

    views = listOf(
        info {
            title(text { value = "Create Account" })
            subTitle(text { value = "Fill in your details" })
            primaryInfo(
                collection {
                    label(text { value = "Personal Info" })
                    values(
                        input {
                            binding = MySchema.user.firstName
                            label(text { value = "First Name" })
                        },
                        input {
                            binding = MySchema.user.email
                            label(text { value = "Email" })
                        },
                        choice {
                            title(text { value = "Select a plan" })
                            binding = MySchema.user.plan
                            items(
                                mapOf("id" to "free", "label" to "Free", "value" to "free"),
                                mapOf("id" to "pro", "label" to "Pro", "value" to "pro"),
                            )
                        },
                    )
                },
            )
            actions(
                action {
                    value = "submit"
                    label(text { value = "Register" })
                    metaData { role = "primary" }
                },
                action {
                    value = "cancel"
                    label(text { value = "Cancel" })
                },
            )
        },
    )

    data = mapOf(
        "user" to mapOf(
            "firstName" to "",
            "email" to "",
            "plan" to "free",
        ),
    )

    navigation {
        flow("FLOW_1") {
            startState = "VIEW_registration"
            view("VIEW_registration", ref = "registration-views-0") {
                on("submit", "END_Done")
                on("cancel", "END_Cancelled")
            }
            end("END_Done", outcome = "done")
            end("END_Cancelled", outcome = "cancelled")
        }
    }
}

Navigation

The navigation DSL supports VIEW, ACTION, and END states:

navigation {
    flow("FLOW_1") {
        startState = "VIEW_step1"

        // VIEW states reference a view by index
        view("VIEW_step1", ref = "my-flow-views-0") {
            on("next", "ACTION_validate")
            on("cancel", "END_Cancelled")
        }

        // ACTION states run an expression, then transition based on result
        action("ACTION_validate", expression<Unit>("validate()")) {
            on("success", "VIEW_step2")
            on("error", "VIEW_step1")
        }

        view("VIEW_step2", ref = "my-flow-views-1") {
            on("submit", "END_Done")
            on("back", "VIEW_step1")
        }

        // END states terminate the flow with an outcome
        end("END_Done", outcome = "done")
        end("END_Cancelled", outcome = "cancelled")
    }
}

Templates

Templates generate dynamic or static repeated content from data arrays:

import com.intuit.playerui.lang.dsl.core.TemplateConfig

// Dynamic template: items re-render when data changes
val userList = collection {
    label(text { value = "Users" })
}.apply {
    template { _ ->
        TemplateConfig(
            data = "{{users}}",
            output = "values",
            value = text { value(MySchema.user.firstName) },
            dynamic = true,
        )
    }
}

// Static template: items are rendered once at load time
val itemList = collection {
    label(text { value = "Items" })
}.apply {
    template { _ ->
        TemplateConfig(
            data = "{{items}}",
            output = "values",
            value = text { value(MySchema.items.label) },
            dynamic = false,
        )
    }
}

Switches

Switches provide conditional content selection, useful for i18n, A/B testing, or feature flags:

import com.intuit.playerui.lang.dsl.core.SwitchArgs
import com.intuit.playerui.lang.dsl.core.SwitchCase
import com.intuit.playerui.lang.dsl.core.SwitchCondition

// Static switch: evaluated once at load time (good for i18n)
val localizedLabel = collection {
    label(text { value = "Hello" })  // default/fallback
}.apply {
    switch(
        path = listOf("label"),
        args = SwitchArgs(
            cases = listOf(
                SwitchCase(
                    SwitchCondition.Dynamic(expression<Boolean>("locale == 'es'")),
                    text { value = "Hola" },
                ),
                SwitchCase(
                    SwitchCondition.Dynamic(expression<Boolean>("locale == 'fr'")),
                    text { value = "Bonjour" },
                ),
                SwitchCase(
                    SwitchCondition.Static(true),  // default case
                    text { value = "Hello" },
                ),
            ),
            isDynamic = false,
        ),
    )
}

// Dynamic switch: re-evaluated when conditions change
val conditionalContent = collection {
    label(text { value = "Default" })
}.apply {
    switch(
        path = listOf("label"),
        args = SwitchArgs(
            cases = listOf(
                SwitchCase(
                    SwitchCondition.Dynamic(expression<Boolean>("showWelcome")),
                    text { value = "Welcome!" },
                ),
                SwitchCase(
                    SwitchCondition.Static(true),
                    text { value = "Goodbye!" },
                ),
            ),
            isDynamic = true,
        ),
    )
}

@rafbcampos rafbcampos self-assigned this Feb 25, 2026
@codecov
Copy link

codecov bot commented Feb 25, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 76.89%. Comparing base (6d7dc1c) to head (bc459ae).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main       #6   +/-   ##
=======================================
  Coverage   76.89%   76.89%           
=======================================
  Files         109      109           
  Lines       10133    10133           
  Branches     1764     1764           
=======================================
  Hits         7792     7792           
  Misses       2322     2322           
  Partials       19       19           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant